iT邦幫忙

0

DimTrip - 讓 AI 助手直接改真正的行程:權限、即時同步和串流踩過的坑

  • 分享至 

  • xImage
  •  

現在很多 AI 旅遊工具,本質上是「很會講話的建議產生器」:它給你一段漂亮的行程文字,然後你要自己把每個景點、每個時間搬進行程表,一起出發的朋友再各抄一份。

我們做 DimTrip 時決定反過來:AI 不寫一段文字給你,而是直接在那份大家共用的行程上動手。這聽起來只是介面上的差別,實際上改變了整個架構 —— AI 從「聊天功能」變成「一個會寫資料庫的協作者」,權限、同步、錯誤處理都得重新想一次。這篇整理我們怎麼做,以及一路上踩到的坑。

一、專案資訊

  • 專案名稱:DimTrip
  • 專案簡介:用 AI 助手規劃旅行的網頁 App。跟它說想去哪、玩幾天,它會直接建立行程、排好每天的景點;之後加景點、記航班和住宿、記帳、整理行李清單,也都是一句話。它改的是一份多人即時共編的真實行程。
  • 開發狀態:已上線,持續更新中

二、實作連結

三、技術架構

  • 前端:React 19 + Vite + Tailwind CSS
  • 資料:Supabase
  • AI 助手:Cloudflare Workers (Hono + Vercel AI SDK)
  • 工具:模型看得到 57 個讀寫行程資料的工具(建立行程、新增或移動景點、記花費、整理清單……),加上 4 個只在瀏覽器執行的畫面工具(例如切換頁面、聚焦某個景點)。資料工具和 App 共用同一套資料層程式,在 Worker 裡直接 import,不再多繞一層 HTTP 服務。

四、核心邏輯與技術挑戰

1. AI 用「使用者本人」的權限寫資料

瀏覽器把使用者自己的 access token 帶給 AI 伺服器,伺服器原封不動地拿它去建立 Supabase client,所以 AI 的每一次讀寫都經過同一套 RLS。

這換到一個很好推理的性質:AI 能改的範圍,就是這個使用者本來就能改的範圍。 權限判斷只寫在資料庫一個地方,不需要在 AI 那層再實作一次,也不用擔心兩邊的規則慢慢長歪。

2. AI 是「另一個協作者」,不需要專屬的同步管道

多人共編本來就靠 Supabase Realtime,把每一筆變更推給同一趟行程的所有人。AI 的寫入走的是同一個資料庫、同一組頻道,所以朋友畫面上出現 AI 新增的景點,跟出現另一個人新增的景點完全一樣 —— Realtime 那一層的程式碼,完全不知道 AI 的存在。

3. 讓人看得到 AI 做了什麼

AI 直接改資料,最大的風險是「它到底改了什麼,我不知道」。我們做了三件事:

  • 跟著看:新增和修改會直接完成。畫面會跳到這一回合第一個改動的地方,而每一個改動都會在原處被標示出來。
  • 刪除要先問:14 個刪除類的工具,在伺服器端刻意不給 execute。模型只能「提出」刪除,前端顯示確認卡片;使用者按下確認後,刪除是由瀏覽器走 App 自己的 store 執行,跟使用者手動按刪除是同一條路。可以自己開啟自動核准,預設是關閉的。
  • 刪錯可以還原:刪掉的景點和花費可以還原。還原是 App 直接把資料加回去(跟手動新增同一條路,也會同步給其他人),不是再送一則訊息請 AI 幫忙。

4. 踩坑一:長回合在串流途中默默斷掉

叫 AI「幫我排一趟五天的行程」,它會連續呼叫工具好幾分鐘,其中有些工具呼叫會安靜很久、完全沒有輸出。閒置這麼久的回應,在 Worker 和瀏覽器之間被切斷了:前端顯示錯誤,使用者按「重試」,模型就再建一趟一模一樣的行程。

解法有三個部分:

  • 心跳:生成期間,回應串流每 10 秒寫入一個暫時性的 data-heartbeat 片段,連線就不會閒置;前端的 useChat 會丟掉暫時性片段,所以不會出現在對話裡。Worker 端另外用 waitUntil 把生成跑完,前端就算真的斷線,對話記錄還是會存下來。
  • 不建出雙胞胎create_trip 如果發現同一位使用者在 15 分鐘內建過同名、同起訖日期的行程,就直接回傳那一趟(標記 reused: true),不再建第二趟。
  • 卡住的對話:刪除確認是在瀏覽器端完成的。如果使用者沒回應確認、直接打了別的字,那個工具呼叫就永遠沒有結果,之後每一回合都會報錯。現在送給模型時會略過沒有結果的工具呼叫(ignoreIncompleteToolCalls),對話不會再被卡死。

5. 踩坑二:一個長回合讓 React 丟出錯誤 #185

換成串流更快的模型之後,長回合開始隨機出現「Something went wrong」。追下去是 React 的錯誤 #185(Maximum update depth exceeded)。

原因是兩件事疊在一起:useChat 每收到一個串流片段就通知 React 更新一次,一個長回合的片段估計有上萬個;同時有一個 effect 會在 render 之後呼叫 setSuggestions([]) —— 就算清單本來就是空的,這個呼叫還是會排進一次新的 render。只要一次網路讀取剛好帶進大約 50 個片段,巢狀更新的次數就超過 React 允許的 50 次,整個畫面直接中止。

解法是把更新合併,再把多餘的 setState 擋掉:

const chat = useChat({
  id: threadId,
  transport,
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
  experimental_throttle: 50, // 串流更新合併成每 50ms 一次
});

// effect 裡:沒有建議要清,就不要 setState
if (hasSuggestionsRef.current) setSuggestions([]);

另外補了一個 e2e 測試,在同一個回應裡一次灌進 2,000 個串流片段,確認這個錯誤不會再回來。

6. 踩坑三:重新打開對話,前端把上次的動作又做了一次

對話記錄會存進資料庫(每則訊息的 parts 存成 JSONB),重新打開時還原成訊息列表。問題在於:如果上次最後一個前端工具呼叫還沒有結果,還原之後前端會把它當成「剛收到的新呼叫」照樣執行,然後自動把結果送回去,讓 AI 接續一個早就結束的回合;開著自動核准的話,還原出來的刪除也會被直接套用。

解法是在還原時分兩種處理:

  • 還沒執行的畫面工具(例如切換頁面),一律標記成已結束,結果寫明「沒有執行:對話在執行前被重新打開」;
  • 還沒確認的刪除保留為待確認,但記住它的 id,自動核准一律跳過 —— 重新打開的對話裡,刪除一定要使用者再按一次確認。

這個坑背後的原則值得記下來:從資料庫還原的狀態,不是新的事件。

7. 截圖和 PDF:讓訂位確認直接變成行程

訂位確認常常是一張截圖或一份 PDF。圖片的難點其實不是「傳圖」,而是順序:使用者可能寫「這是飯店〔圖〕,這是航班〔圖〕,兩個都加進去」。SDK 預設的格式會把所有檔案放在文字前面,模型就分不清哪張圖對應哪句話。

我們在插入圖片的游標位置放一個 [image N] 標記,送出時依照標記把文字切開,變成「文字、圖片、文字、圖片」依序排列的片段,每張圖都緊跟在描述它的那段文字後面。每則訊息最多 6 張圖,送出前在瀏覽器裡縮到最長邊 1600px 的 JPEG,每張不超過 2MB。

PDF 則不另外做文字抽取:pdf.js 在第一次用到時才載入、跑在 Web Worker 裡,把每一頁畫成圖片,走跟截圖同一條路 —— 程式碼只有一條路徑,掃描檔也一樣處理。Android 手機上還可以從系統的分享選單,把檔案直接丟進 DimTrip 的輸入框(PWA 的 share target;iOS Safari 不支援),檔案只會放進輸入框,不會自動送出。

8. 讓前綴保持穩定

AI 需要知道「使用者現在在看哪一天、哪個畫面」,但這些資訊每一回合都在變。我們不把它塞進 system prompt,而是讓前端用另一個欄位送過來,伺服器只附加在送給模型的最後一則使用者訊息上,而且不會存進對話記錄。system prompt 只放語系、今天日期、主要貨幣這類同一天內不會變的資訊,加上固定的工具定義,同一位使用者當天的每一回合,前綴都一樣,前綴快取才有機會命中。

五、還在想的事

最想知道的是:大家實際會叫 AI 助手做什麼,以及在哪些情境下,它的改動不如預期。

如果你也在做「讓 AI 直接動手改資料」的產品,很想交流權限和確認流程怎麼設計 —— 例如哪些動作該直接做、哪些該先問,歡迎留言討論。


*提醒邦友,使用第三方服務/API 時,請務必評估資安風險與隱私保護
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言